CloudFormation StackSets を使用してクロスアカウントと複数リージョンにパブリックACM証明書をデプロイする
はじめに
おはようございます、もきゅりんです。
皆さん、Route53で管理しているドメインのアカウントとは別アカウントで、ACMの証明書発行のDNS検証を自動でやりたい、と思ったことはないでしょうか。
同じアカウントだったら何も迷わずできますよね。
でも、別々のアカウントにあるんだよなー困ったなーこれはもうカスタムリソースでやるしかないよなーと思っていたら、こんなのがありました。
頑張ってコードを書いたりしないで探してみて良かったー。
ということで、今回はこちらを対応します。
このブログでは実際にスタックセットを準備して実行するところまでは記載されていないので、やってみましょう。
作るもの
今回構築する内容のイメージは下図になります。
実行フロー概要
- 管理アカウントから CloudFormation スタックセットを実行
- 管理アカウントのS3のZipファイルから被管理アカウントにLambdaを作成
- LambdaはACMにパブリック証明書をリクエスト
- ACMから発行されたCNAMEレコードと値を管理アカウントのRoute53の対象ドメインに登録
やること
- スタックセットの準備
- 管理アカウントにクロスアカウントアクセス用のIAMロール作成
- 管理アカウントのS3バケットにバケットポリシー付与
- スタックセットの実行
前提
- Route53にパブリックホストゾーンでドメイン管理していること
やってみる
1 スタックセットの準備
スタックセットオペレーションの前提条件 - AWS CloudFormation を参照に進めて貰えれば、特に厄介なことはありません。
すでにスタックセットを実行できる環境が設定されている方は、2. 管理アカウントにクロスアカウントアクセス用のIAMロール作成、に進んでしまいましょう。
本稿では、セルフマネージド型のアクセス許可を持つスタックセットを作成するために必要なアクセス許可を設定します。
管理アカウントには AWSCloudFormationStackSetAdministrationRole を作成し、被管理アカウントには AWSCloudFormationStackSetExecutionRole を作成します。
それぞれ AWS からテンプレートが提供されています。
なお、下記のような注意がなされていますので留意下さい。
AWSCloudFormationStackSetExecutionRole のテンプレートは管理者アクセス権を付与することに注意してください。
テンプレートを使用してターゲットアカウント実行ロールを作成した後、ポリシーステートメントのアクセス許可を、StackSets を使用して作成するリソースの種類にスコープする必要があります。
画面キャプチャを見ながら進めたい方は、2017年の古い記事なのですが、下記弊社ブログを参照して頂くと宜しいかと思います。
ドキュメントでは セルフマネージド型のアクセス許可を付与する - AWS CloudFormation の内容です。
以上でスタックセットを利用する下準備は整いました。
2 管理アカウントにクロスアカウントアクセス用のIAMロール作成
管理アカウントに、被管理アカウントから実行される Lambda が利用する IAMロールを作成します。
図で枠で囲ってあるところを作成します。
CloudFormation テンプレートは、 How to deploy public ACM certificates across multiple AWS accounts and Regions using AWS CloudFormation StackSets | AWS Security Blog の Global-resources CloudFormation template を参照下さい。
被管理アカウントのリソースから Route53のレコードを操作する権限と対象のホストゾーンのレコードセットを取得できる権限を付与します。
RoleArn は後ほどパラメータで利用するので控えておくと良いでしょう。
3 管理アカウントのS3バケットにバケットポリシー付与
Lambdaのソースコード Zipを保存するS3バケットに AWSCloudFormationStackSetExecutionRole からのアクセスを許可するバケットポリシーを付与します。
図で枠で囲ってあるところの設定です。
下記のCloudFormation テンプレートでS3バケットからバケットポリシーまでを作ってしまいますが、既存のバケットに設定したい場合は、バケットポリシーのみを作成で構いません。
AWSTemplateFormatVersion: 2010-09-09 # ------------------------------------------------------------# # Parameters # ------------------------------------------------------------# Parameters: ENV: Type: String Default: 'dev' Description: Prefix of Env tags. CFnStackSetExecutionRole: Type: String Description: StackSetExecutionRole of Target Accounts. # ------------------------------------------------------------# # Resources # ------------------------------------------------------------# Resources: AssetsBucket: Type: AWS::S3::Bucket Properties: BucketName: !Sub ${ENV}-${AWS::AccountId}-acm-cname-append-lambda PublicAccessBlockConfiguration: BlockPublicAcls: True BlockPublicPolicy: True IgnorePublicAcls: True RestrictPublicBuckets: True BucketEncryption: ServerSideEncryptionConfiguration: - BucketKeyEnabled: True ServerSideEncryptionByDefault: SSEAlgorithm: AES256 # DeletionPolicy: Retain SampleBucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref AssetsBucket PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: AWS: !Ref CFnStackSetExecutionRole Action: - s3:GetObject Resource: !Join - '' - - 'arn:aws:s3:::' - !Ref AssetsBucket - /* # ------------------------------------------------------------# # Outputs # ------------------------------------------------------------# Outputs: BucketName: Value: !Ref AssetsBucket
こちら からパッケージをダウンロードしてS3にアップロードします。
バケット名、ファイル名は後ほどパラメータで利用するので控えておくと良いでしょう。
4 スタックセットの実行
図で枠で囲ってあるところの設定です。(本稿での被管理アカウントは1つです)
CloudFormation テンプレートは 上記参考ブログの Cross-account CloudFormation template を参照下さい。
本稿では、Lambda のソースコードを簡単に確認しておきます。
Lambda(Python)コードの簡単な確認
import json import requests import boto3 import botocore import time sts_conn=boto3.client('sts') def sendResponse(event, context, responseStatus, responseData={}, reason=None, physical_resource_id=None): responseBody = {'Status': responseStatus, 'Reason': 'See the details in CloudWatch Log Stream: ' + context.log_stream_name, 'PhysicalResourceId': physical_resource_id or context.log_stream_name, 'StackId': event['StackId'], 'RequestId': event['RequestId'], 'LogicalResourceId': event['LogicalResourceId'], 'Data': responseData} print ('RESPONSE BODY:n' + json.dumps(responseBody)) responseUrl = event['ResponseURL'] json_responseBody = json.dumps(responseBody) try: response = requests.put(responseUrl, data=json_responseBody) print ("Status code: " + response.reason) except Exception as e: print ("send(..) failed executing requests.put(..): " + str(e)) def lambda_handler(event, context): domain_name=event['ResourceProperties']['DomainName'] region_name=event['ResourceProperties']['Regions'] HzoneID=event['ResourceProperties']['HostedZone'] SAN=event['ResourceProperties']['SAN'] Role=event['ResourceProperties']['RoleARN'] parent_acc_conn = sts_conn.assume_role(RoleArn=Role,RoleSessionName='cross_acc_lambda') ACCESS_KEY = parent_acc_conn['Credentials']['AccessKeyId'] SECRET_KEY = parent_acc_conn['Credentials']['SecretAccessKey'] SESSION_TOKEN = parent_acc_conn['Credentials']['SessionToken'] r53 = boto3.client('route53',aws_access_key_id=ACCESS_KEY,aws_secret_access_key=SECRET_KEY,aws_session_token=SESSION_TOKEN) responseData={} if event['RequestType'] == 'Delete': sendResponse(event, context, 'SUCCESS') elif event['RequestType'] == 'Create' or event['RequestType'] == 'Update': for x in region_name: acm=boto3.client('acm', region_name=x) cert_arn=acm.request_certificate(DomainName=domain_name,ValidationMethod='DNS',SubjectAlternativeNames=SAN)['CertificateArn'] time.sleep(15) cert_cnames=list() cert=acm.describe_certificate(CertificateArn=cert_arn)['Certificate']['DomainValidationOptions'] for domain in cert: cert_cnames.append([domain['ResourceRecord']['Name'],domain['ResourceRecord']['Value']]) for iter in cert_cnames: rr=r53.change_resource_record_sets(HostedZoneId=HzoneID, ChangeBatch={'Changes': [{'Action': 'UPSERT','ResourceRecordSet': {'Name': iter[0],'Type':'CNAME','TTL': 120,'ResourceRecords': [{'Value': iter[1]}]}}]}) sendResponse(event, context, 'SUCCESS') else: sendResponse(event, context, 'SUCCESS') return
ハイライト部分を順に追っていきます。
sendResponse 関数からです。
基本的に json に項目をまとめて返してあげているだけです。
この関数は、cfn-応答モジュール- に記載されている cfn-reponse モジュールソースコードとほぼ同様です。
こちらは、そもそもカスタムリソースの仕組みの話になりますが、CloudFormation テンプレートを使用してカスタムリソースを作成、更新、または削除する際に、カスタムリソースはリクエストに応じてレスポンスする必要があります。
Lambda を用いたカスタムリソースでは、 直接インラインでテンプレートに ZipFileプロパティを使用して関数のソースコードを指定する方法と、S3バケットにZipファイルで Lambdaのコードを保存して参照する方法があります。
そして、直接インラインでテンプレート上にコードを記載する方法だと上記のリクエストに応じたレスポンスをコードで実装しなくても cfn-response モジュール が行ってくれます。
しかし、AmazonS3バケットに保存されているソースコードでは利用できず、レスポンスを送信するためには独自関数を実装する必要があります。
その関数が sendResponse 関数になります。
詳細は、 カスタムリソース - AWS CloudFormation 、cfn-応答モジュール- をご参照下さい。
次は、Lambda関数の本体です。
カスタムリソースの Properties の変数に CloudFormation のパラメータで指定されたものが入力されてきます。
それぞれ、ドメインネーム, リージョン, HostedZoneId, SAN (SubjectAlternativeName: 証明書を作成するための追加ドメイン名), RoleARN (Route53を編集するロールのARN) などになります。
指定した RoleARN から assume_role のリクエストして STS から一時認証情報を取得します。
その一時認証を使って、Route53の編集する権限を得ます。
リストで受け取ったリージョンごとにforで繰り返して、DNS検証リクエストします。
発行されたACMの証明書ARNをレスポンスから取得します。
リージョンが異なっていても設定するCNAMEのレコード名と値は同じなので1つだけ取得します。
ACMから発行されたDNS設定に追加するためのCNAMEレコードをすべて取得してRoute53にCNAMEレコードで追加します。
コンソールから実行する
CloudFormationのコンソールから以下のイメージのように進めます。
設定されている箇所以外は、任意またはデフォルトで支障ありません。
実行すると、オペレーション画面にこのような結果が表示されるはずです。
被管理アカウントのACMにはドメイン名が追加され、管理アカウントのドメインレコードが追加されているかと思います。
更新や追加したい場合は、スタックセットのパラメータを上書きして実行しましょう。
さいごに
今後見込まれる管理コストとの相談になりますが、スタックセットを使わずに、各被管理アカウント内にS3バケットを作成して、Lambdaソースコードを配置して利用する、といった使い方も可能です。
ブログを見つけたことで、余計な時間をかけずに助かりました。
とはいえ、検証と中身の確認するのにそれなりの時間はかかるのですが。
以上です。
どこかのどなたかのお役に立てば幸いです。